// SPDX-License-Identifier: Apache-2.0
import type { FnU } from "@thi.ng/api";
import { deref, isDeref } from "@thi.ng/api/deref";
import { implementsFunction } from "@thi.ng/checks/implements-function";
import { isArray } from "@thi.ng/checks/is-array";
import { isFunction } from "@thi.ng/checks/is-function";
import { isNotStringAndIterable } from "@thi.ng/checks/is-not-string-iterable";
import { isPlainObject } from "@thi.ng/checks/is-plain-object";
import { isString } from "@thi.ng/checks/is-string";
import { illegalArgs } from "@thi.ng/errors/illegal-arguments";
import { escapeEntitiesNum } from "@thi.ng/strings/entities";
import {
	ATTRIB_JOIN_DELIMS,
	CDATA,
	COMMENT,
	INLINE,
	NO_CLOSE_EMPTY,
	NO_SPANS,
	PROC_TAGS,
	VOID_TAGS,
} from "./api.js";
import { css } from "./css.js";
import type { derefContext } from "./deref.js";
import { normalize } from "./normalize.js";
import { formatPrefixes } from "./prefix.js";

/**
 * Options to customize the behavior of {@link serialize}.
 */
export interface SerializeOpts {
	/**
	 * Arbitrary user context object
	 */
	ctx?: any;
	/**
	 * If true, auto-escape entities via {@link SerializeOpts.escapeFn}.
	 *
	 * @defaultValue false
	 */
	escape: boolean;
	/**
	 * Only used if {@link SerializeOpts.escape} is enabled. Function to escape
	 * entities. By default uses
	 * [`escapeEntitiesNum`](https://docs.thi.ng/umbrella/strings/functions/escapeEntitiesNum.html).
	 */
	escapeFn: FnU<string>;
	/**
	 * If true (default: false), all text content will be wrapped in `<span>`
	 * elements (to ensure DOM compatibility with hdom). The only elements for
	 * spans are never created are listed in {@link NO_SPANS}.
	 *
	 * @defaultValue false
	 */
	span: boolean;
	/**
	 * If true (default: false), all elements will have an autogenerated `key`
	 * attribute injected. If {@link SerializeOpts.span} is enabled, `keys` will
	 * be enabled by default too (since in this case we assume the output is
	 * meant to be compatible with [`thi.ng/hdom`](https://thi.ng/hdom)).
	 *
	 * @defaultValue false
	 */
	keys: boolean;
	/**
	 * If true, some serialization behaviors will be adjusted to target XML
	 * output.
	 *
	 * @remarks
	 * Currently, the following aspects are supported:
	 *
	 * - boolean attributes are serialized as `name=""` rather than just `name`
	 *
	 * @defaultValue false
	 */
	xml: boolean;
}

/**
 * Recursively normalizes and serializes given tree as HTML/SVG/XML string.
 * Expands any embedded component functions with their results.
 *
 * @remarks
 * Each node of the input tree can have one of the following input forms:
 *
 * ```js
 * ["tag", ...]
 * ["tag#id.class1.class2", ...]
 * ["tag", {other: "attrib"}, ...]
 * ["tag", {...}, "body", function, ...]
 * [function, arg1, arg2, ...]
 * [{render: (ctx,...) => [...]}, args...]
 * iterable
 * ```
 *
 * Tags can be defined in "Emmet" convention, e.g.
 *
 * ```js
 * ["div#foo.bar.baz", "hi"] // <div id="foo" class="bar baz">hi</div>
 * ```
 *
 * The presence of the attributes object (2nd array index) is optional. Any
 * attribute values, incl. functions are allowed. If the latter, the function is
 * called with the full attribs object as argument and the return value is used
 * for the attribute. This allows for the dynamic creation of attrib values
 * based on other attribs. The only exception to this are event attributes, i.e.
 * attribute names starting with "on". Function values assigned to event
 * attributes will be omitted from the output.
 *
 * ```js
 * ["div#foo", { bar: (attribs) => attribs.id + "-bar" }]
 * // <div id="foo" bar="foo-bar"></div>
 * ```
 *
 * The `style` attribute can ONLY be defined as string or object.
 *
 * ```js
 * ["div", { style: { color: "red", background: "#000" } }]
 * // <div style="color:red;background:#000;"></div>
 * ```
 *
 * Boolean attribs are serialized in HTML5 syntax (present or not). `null`,
 * `undefined` or empty string attrib values are ignored.
 *
 * Any `null` or `undefined` array values (other than in head position) will
 * also be removed, unless a function is in head position.
 *
 * A function in head position of a node acts as a mechanism for component
 * composition & delayed execution. The function will only be executed at
 * serialization time. In this case the optional global context object and all
 * other elements of that node / array are passed as arguments when that
 * function is called. The return value the function MUST be a valid new tree
 * (or `undefined`).
 *
 * If the `ctx` option is given it'll be passed to each embedded component fns.
 * Optionally call {@link derefContext} prior to `serialize()` to auto-deref
 * context keys with values implementing the
 * [`IDeref`](https://docs.thi.ng/umbrella/api/interfaces/IDeref.html)
 * interface.
 *
 * ```js
 * import { serialize } from "@thi.ng/hiccup";
 *
 * const foo = (ctx, a, b) => ["div#" + a, ctx.foo, b];
 *
 * serialize([foo, "id", "body"], { ctx: { foo: { class: "black" } } })
 * // <div id="id" class="black">body</div>
 * ```
 *
 * Functions located in other positions are called ONLY with the global context
 * arg and can return any (serializable) value (i.e. new trees, strings,
 * numbers, iterables or any type with a suitable `.toString()`, `.toHiccup()`
 * or `.deref()` implementation).
 *
 * hiccup & hdom control attributes (i.e. attrib names prefixed with `__`) will
 * be omitted from the output. The only control attrib supported by this package
 * is `__serialize`. If set to `false`, the entire tree branch below (and
 * including) the element with that attrib will be excluded from the output.
 *
 * **See {@link SerializeOpts} for further available options.**
 *
 * Single or multiline comments can be included using the special `COMMENT` tag
 * (`"__COMMENT__"`) (always WITHOUT attributes!).
 *
 * ```js
 * import { COMMENT } from "@thi.ng/hiccup";
 *
 * [COMMENT, "Hello world"]
 * // serializes to:
 * // <!-- Hello world -->
 *
 * [COMMENT, "Hello", "world"]
 * // <!--
 * //     Hello
 * //     world
 * // -->
 * ```
 *
 * Currently, the only processing / DTD instructions supported are:
 *
 * - `?xml`
 * - `!DOCTYTPE`
 * - `!ELEMENT`
 * - `!ENTITY`
 * - `!ATTLIST`
 *
 * These are used as follows (attribs are only allowed for `?xml`, all others
 * only accept a body string which is taken as is):
 *
 * ```js
 * serialize(["?xml", { version: "1.0", standalone: "yes" }])
 * // <?xml version="1.0" standalone="yes"?>
 *
 * ["!DOCTYPE", "html"] // (also available as DOCTYPE_HTML)
 * // <!DOCTYPE html>
 * ```
 *
 * @param tree - hiccup elements / component tree
 * @param opts - options
 */
export const serialize = (
	tree: any,
	opts?: Partial<SerializeOpts>,
	path = [0]
) => {
	const $opts = {
		escape: false,
		escapeFn: escapeEntitiesNum,
		keys: false,
		span: false,
		xml: false,
		...opts,
	};
	if (opts?.keys == null && $opts.span) $opts.keys = true;
	return __serialize(tree, $opts, path);
};

/** @internal */
const __serialize = (tree: any, opts: SerializeOpts, path: any[]): string =>
	tree == null
		? ""
		: Array.isArray(tree)
		? __serializeElement(tree, opts, path)
		: isFunction(tree)
		? __serialize(tree(opts.ctx), opts, path)
		: implementsFunction(tree, "toHiccup")
		? __serialize(tree.toHiccup(opts.ctx), opts, path)
		: isDeref(tree)
		? __serialize(tree.deref(), opts, path)
		: isNotStringAndIterable(tree)
		? __serializeIter(tree, opts, path)
		: ((tree = __escape(String(tree), opts)), opts.span)
		? `<span${opts.keys ? ` key="${path.join("-")}"` : ""}>${tree}</span>`
		: tree;

/** @internal */
const __serializeElement = (tree: any[], opts: SerializeOpts, path: any[]) => {
	let tag = tree[0];
	return !tree.length
		? ""
		: isFunction(tag)
		? __serialize(tag.apply(null, [opts.ctx, ...tree.slice(1)]), opts, path)
		: implementsFunction(tag, "render")
		? __serialize(
				tag.render.apply(null, [opts.ctx, ...tree.slice(1)]),
				opts,
				path
		  )
		: tag === COMMENT
		? __serializeComment(tree)
		: tag === INLINE
		? __serializeInline(tree)
		: tag == CDATA
		? __serializeCData(tree)
		: isString(tag)
		? __serializeTag(tree, opts, path)
		: isNotStringAndIterable(tree)
		? __serializeIter(tree, opts, path)
		: illegalArgs(`invalid tree node: ${tree}`);
};

/** @internal */
const __serializeTag = (tree: any[], opts: SerializeOpts, path: any[]) => {
	tree = normalize(tree);
	const attribs = tree[1];
	if (attribs.__skip || attribs.__serialize === false) return "";
	opts.keys && attribs.key === undefined && (attribs.key = path.join("-"));
	if (attribs.__escape != null) opts = { ...opts, escape: attribs.__escape };
	const tag = tree[0];
	const body = tree[2]
		? __serializeBody(tag, tree[2], opts, path)
		: !VOID_TAGS[tag] && !NO_CLOSE_EMPTY[tag]
		? `></${tag}>`
		: PROC_TAGS[tag] || "/>";
	return `<${tag}${__serializeAttribs(attribs, opts)}${body}`;
};

/** @internal */
const __serializeAttribs = (attribs: any, opts: SerializeOpts) => {
	let res = "";
	for (const a in attribs) {
		if (a.startsWith("__")) continue;
		const v = __serializeAttrib(attribs, a, deref(attribs[a]), opts);
		v != null && (res += v);
	}
	return res;
};

/** @internal */
const __serializeAttrib = (
	attribs: any,
	a: string,
	v: any,
	opts: SerializeOpts
) => {
	return v == null
		? null
		: isFunction(v) && (/^on\w+/.test(a) || (v = v(attribs)) == null)
		? null
		: v === true
		? " " + a + (opts.xml ? `=""` : "")
		: v === false
		? null
		: a === "data"
		? __serializeDataAttribs(v, opts)
		: __attribPair(a, v, opts);
};

/** @internal */
const __attribPair = (a: string, v: any, opts: SerializeOpts) => {
	v =
		a === "style" && isPlainObject(v)
			? css(v)
			: a === "prefix" && isPlainObject(v)
			? formatPrefixes(v)
			: isArray(v)
			? v.join(ATTRIB_JOIN_DELIMS[a] || " ")
			: v.toString();
	return v.length ? ` ${a}="${__escape(v, opts)}"` : null;
};

/** @internal */
const __serializeDataAttribs = (data: any, opts: SerializeOpts) => {
	let res = "";
	for (const id in data) {
		let v = deref(data[id]);
		isFunction(v) && (v = v(data));
		v != null && (res += ` data-${id}="${__escape(v, opts)}"`);
	}
	return res;
};

/** @internal */
const __serializeBody = (
	tag: string,
	body: any[],
	opts: SerializeOpts,
	path: any[]
) => {
	if (VOID_TAGS[tag]) {
		illegalArgs(`No body allowed in tag: ${tag}`);
	}
	const proc = PROC_TAGS[tag];
	let res = proc ? " " : ">";
	if (opts.span && !proc && !NO_SPANS[tag]) opts = { ...opts, span: true };
	for (let i = 0, n = body.length; i < n; i++) {
		res += __serialize(body[i], opts, [...path, i]);
	}
	return res + (proc || `</${tag}>`);
};

/** @internal */
const __serializeComment = (tree: any[]) =>
	tree.length > 2
		? `\n<!--\n${tree
				.slice(1)
				.map((x) => "    " + x)
				.join("\n")}\n-->\n`
		: `\n<!-- ${tree[1]} -->\n`;

/** @internal */
const __serializeInline = (tree: any[]) => tree.slice(1).join("");

/** @internal */
const __serializeCData = (tree: any[]) =>
	`<![CDATA[\n${tree.slice(1).join("\n")}\n]]>`;

/** @internal */
const __serializeIter = (
	iter: Iterable<any>,
	opts: SerializeOpts,
	path: any[]
) => {
	const res: any[] = [];
	const p = path.slice(0, path.length - 1);
	let k = 0;
	for (const i of iter) {
		res.push(__serialize(i, opts, [...p, k++]));
	}
	return res.join("");
};

/** @internal */
const __escape = (x: string, opts: SerializeOpts) =>
	opts.escape ? opts.escapeFn(x) : x;
